Skip to content

fix: accept IDs as well as names for --channel, --environment and --tenant - #702

Draft
NickJosevski wants to merge 5 commits into
mainfrom
nj/issue-250
Draft

NickJosevski wants to merge 5 commits into
mainfrom
nj/issue-250

Conversation

@NickJosevski

Copy link
Copy Markdown
Contributor

Fixes #250

The bug

The reporter's build tooling uses IDs everywhere (names change, IDs don't). --project accepts an ID, but --channel, --environment and --tenant did not:

Octopus API error: Channel named 'Channels-681' within project '<name>' was not found.
Octopus API error: Environment named 'Environments-201' within project '<name>' was not found.

Root cause: the executions API (releases/create/v1, deployments/create/{un}tenanted/v1, runbook-runs/create/v1) takes projectName as an ID-or-name — the server resolves it — but channelIDOrName, environmentName(s) and tenants are matched by name only, server side. The CLI passed whatever the caller typed straight through, so IDs blew up.

Name-vs-ID inventory (before this PR)

release create

Flag Resolved by Accepted before
--project selectors.FindProjectProjects.GetByIdentifier ID or name
--channel selectors.FindChannel (interactive) / passed through (--no-prompt) name only
--git-ref, --git-commit passed to server git refs, n/a
--package, --package-version, --git-resource step/package names name only (no ID concept)
--version, --release-notes, --ignore-*, --custom-fields n/a n/a

release deploy

Flag Resolved by Accepted before
--project selectors.FindProject ID or name
--environment executionscommon.FindEnvironments (some paths) / passed through mixed — see note
--tenant passed straight through name only
--tenant-tag canonical tag paths (Regions/us-east) n/a — no ID form
--deployment-target / --exclude-deployment-target SpecificMachineNames / ExcludedMachineNames name only
--skip step names name only
--deployment-freeze-name DeploymentFreezeNames name only
--version, --deploy-at, --variable, … n/a n/a

Note on --environment: executionscommon.FindEnvironments already matched name-or-ID, but (a) only some code paths called it, (b) it never wrote the canonical name back into the options, so the raw ID still went to the server, and (c) the tenanted path used selectors.FindEnvironment, which was name-only.

runbook run

Flag Resolved by Accepted before
--project selectors.ResolveProject ID or name
--snapshot runbooks.GetSnapshot ID or name
--name / --runbook runbooks.GetByName name only
--runbook-tag tags n/a
--environment passed through in automation mode name only
--tenant passed straight through name only
--tenant-tag canonical tag paths n/a
--run-target / --exclude-run-target machine names name only
--skip step names name only

What changed

Everything goes through the shared selectors package, so every caller benefits:

  • selectors.FindEnvironments / FindEnvironment (pkg/question/selectors/environments.go) — the one environment resolver. One GET /environments/all, matched client side by ID first, then name. executionscommon.FindEnvironments now delegates to it, so its ~12 existing callers (target create commands, tenant connect) pick up the consistent precedence for free.
  • selectors.FindChannel — now matches a channel's ID as well as its name, still scoped to the project so an ID from another project is never returned.
  • selectors.FindTenant / FindTenants (new pkg/question/selectors/tenants.go) — wraps Tenants.GetByIdentifier (ID first, then name), turning "not found" into a clear message.
  • release create — resolves --channel in the --no-prompt path and sends the canonical name.
  • release deploy — resolves --tenant for both paths and --environment in the --no-prompt path; the interactive paths now write the resolved canonical name back into the options so what's sent (and what appears in the generated automation command) is the real name, not the raw input.
  • runbook run — resolves --environment and --tenant once, right after the project is resolved, covering the db/git/by-tag and prompt/no-prompt paths.
  • Ephemeral environments aren't in the regular environment list, so release deploy falls back to the ephemeral lookup when the regular one comes up empty (resolveEnvironmentNames).

Precedence rule

Try an exact ID match first, fall back to an exact name match (case-insensitive).

This matches what --project already does (Projects.GetByIdentifier = GetByID, then GetByName) and what channel/shared.ResolveChannel already did. So an entity that is genuinely named Environments-201 loses to the entity whose ID is Environments-201. It is documented in the resolver comments and covered by tests ("prefers an ID match over a name match").

Note this flips the old precedence inside executionscommon.FindEnvironments, which tried name first. That only matters when a name collides with a different entity's ID, and consistency with --project seemed more valuable than preserving the old order.

Error messages

Uniform and explicit about both forms being accepted:

  • cannot find an environment with the ID or name of 'Environments-404'
  • cannot find a channel in project 'Fire Project' with the ID or name of 'Channels-404'
  • cannot find a tenant with the ID or name of 'Tenants-404'

The channel message previously read no channel found with name of X; the two channel test files touched (channel/delete, channel/view) only update that expected string — no behaviour change to those commands.

Test evidence

New tests:

  • pkg/question/selectors/find_test.go — table tests for FindEnvironments/FindEnvironment/FindChannel/FindTenants: by name, by name ignoring case, by ID, several at once, ID beats a colliding name, and the not-found error text.
  • release create — "release creation specifying the project and channel by ID": asserts the POST body carries ChannelIDOrName: "BetaChannel" when --channel Channels-31 was passed.
  • release deploy — "release deploy specifying project, environment and tenant by ID": asserts EnvironmentName: "dev" / Tenants: ["Coke"] when --environment Environments-12 --tenant Tenants-29 was passed.
  • runbook run — "runbook run specifying project, environment and tenant by ID": same assertion for runbook-runs/create/v1.

Existing tests updated for the extra lookup requests (and one deploy test now asserts "Ephemeral Environment" instead of the lowercase "ephemeral environment" the user typed — that normalisation is the fix working).

$ go build ./...
(clean)

$ go test ./pkg/...
ok — 64 packages, 0 failures

go vet ./... reports only two pre-existing unreachable code warnings in pkg/cmd/tenant/variables/list/list.go, untouched here.

Open questions / options

  1. Precedence: ID first, or name first? Chosen: ID first, for consistency with --project. The alternative — name first — would mean an environment named Environments-201 shadows the environment with that ID, which is arguably the more surprising outcome and is inconsistent with --project. A third option is to error on ambiguity, which is the most correct but adds a failure mode to a hot path for a collision nobody has ever reported. Recommendation: keep ID first.

  2. Extra round trip. runbook run --environment X and release deploy --no-prompt --environment X now always do one GET /environments/all (and one or two calls per --tenant) that they previously skipped. That's the price of resolving client side. The alternative is a "looks like an ID" regex (^Environments-\d+$) to skip the lookup for plain names — faster, but it bakes in an ID-format assumption and makes the collision case incoherent. Recommendation: keep the unconditional lookup; revisit only if someone measures a problem.

  3. executionscommon.FindEnvironments is now a one-line alias for selectors.FindEnvironments. I kept it so the ~12 callers in the target-create commands and tenant connect stay untouched and this PR's diff stays on-topic. Recommendation: delete it and update the call sites in a follow-up, in line with the recent selectors.ResolveProject / selectors.Channel consolidation — happy to fold that in here instead if reviewers prefer.

  4. Flags deliberately left alone — all name-only, all out of scope, none reported: --deployment-target / --exclude-deployment-target / --run-target / --exclude-run-target (machine names), --skip (step names), --deployment-freeze-name, --name/--runbook (runbook name), and the package/git-resource step specs. Machines and runbooks would be the natural next candidates if we want ID support everywhere; the server-side commands for those take names only, so each needs the same client-side resolve-then-send treatment. Recommendation: follow-up issue rather than growing this PR. --tenant-tag has no ID form at all — canonical tag paths are the only identifier.

  5. Should release create --channel resolve in interactive mode too? It already did (via selectors.FindChannel), and now picks up ID support for free. Worth a reviewer sanity check that echoing the resolved name back (rather than the ID the user typed) is the desired UX. I think it is — it confirms what the ID pointed at.

🤖 Generated with Claude Code

@NickJosevski NickJosevski left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed for correctness regressions vs the old pass-through behavior. Three findings, inline below. The precedence flip and extra round trips are already covered in the PR description, so no comments on those.

Comment thread pkg/question/selectors/tenants.go Outdated

// FindTenant looks a tenant up by either its ID or its name.
func FindTenant(octopus *octopusApiClient.Client, tenantIdentifier string) (*tenants.Tenant, error) {
tenant, err := octopus.Tenants.GetByIdentifier(tenantIdentifier)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name fallback inside Tenants.GetByIdentifier (GetByName in the SDK) issues a single tenants?partialName=<name> query with no take and scans only the first page (server default page size) for an exact match - it never paginates. partialName is a contains filter, so a tenant whose exact name sorts after a page's worth of other tenants containing the same substring (e.g. --tenant Smith in a space with Aaron Smith, Bob Smith, ... Zoe Smith) resolves to ErrItemNotFound and the deploy fails with cannot find a tenant with the ID or name of 'Smith' - even though that exact name worked before this PR, when it was passed straight through and matched server-side.

Since this can turn a working --tenant <name> into a hard failure on the deploy hot path, consider resolving names with a paginated exact-match lookup (the way the old selectors.FindEnvironment walked GetNextPage) rather than relying on GetByIdentifier's first-page fallback. (Projects.GetByIdentifier has the same latent flaw for --project, but this PR extends the exposure to --tenant on release deploy and runbook run.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in 93ab1c6.

Confirmed against the SDK we pin (go-octopusdeploy/v2 v2.114.1, pkg/tenants/tenant_service.go): GetByIdentifier (L234) falls back to GetByName (L213), which does a single s.Get(TenantsQuery{PartialName: name}) and then scans only tenants.Items — the first page — before returning services.ErrItemNotFound. No take, no GetNextPage. The finding holds exactly as described.

selectors.FindTenant no longer calls GetByIdentifier. It does the ID lookup itself (Tenants.GetByID, treating a 404 as "not an ID, try the name") and then walks every page of the partialName search looking for a case-insensitive exact match, keeping ID-beats-name precedence. The paging loop uses resultPage.GetNextPage(octopus.Tenants.GetClient()), which returns nil, nil when Links.PageNext is empty, so it terminates on the last page.

Covered by TestFindTenants/finds an exact name match beyond the first page of the partial name search in pkg/question/selectors/find_test.go, which serves Aaron Smith on page one with a PageNext link and the exact Smith on page two.

Residual, deliberately not fixed here: Projects.GetByIdentifier still has the same first-page-only name fallback, and selectors.FindProject still goes through it. That's pre-existing on main and this PR doesn't widen it, so I've left it — say the word if you want it pulled in.

PR #703 raises the same tenant-pagination finding; the conclusion there is the same, and the fix lives in selectors.FindTenant, which both branches share.

Comment thread pkg/cmd/release/deploy/deploy.go Outdated

ephemeralEnvironments, ephemeralErr := findEphemeralEnvironments(octopus, space, environmentIdentifiers)
if ephemeralErr != nil {
return nil, err // ephemeral environments are the rarer case; report why the regular lookup failed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the fallback is all-or-nothing over the whole list, a list mixing a regular and an ephemeral environment can never resolve: the regular lookup errors on the ephemeral name, the ephemeral lookup then errors on the regular name, and the user sees cannot find an environment with the ID or name of '<ephemeral env>' - blaming an environment that exists. Before this PR the --no-prompt path passed the names through and left the mix to the server.

If mixed lists are meant to be invalid that's defensible, but the message is misleading. Resolving per-identifier (regular list first, then ephemeral; each list fetched at most once) would keep single-type lists behaving exactly as now, while letting mixed lists either resolve or fail with an accurate message.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in 3d8333b. The finding holds: the fallback was all-or-nothing over the whole list, so a mixed list could never resolve and the error named an environment that exists.

The resolver moved out of deploy.go into selectors.ResolveEnvironmentNames and now works per identifier: each one is matched against the regular environment list first, and the ephemeral list is consulted only for identifiers that list doesn't have — fetched once, lazily, so a single-type list makes exactly the same calls it did before. A genuine miss now names the identifier that actually went missing.

Two things that came out of the rework:

  • it used to fall back on any error from the regular lookup, including a transport failure; now the regular lookup's error is returned as-is and only a miss triggers the ephemeral list.
  • findEphemeralEnvironments indexed name-then-ID, so a name could shadow another environment's ID. It now indexes ID-first, matching the precedence everywhere else.

Covered by TestResolveEnvironmentNames in pkg/question/selectors/find_test.go: resolves a mix of regular and ephemeral environments, doesn't look at ephemeral environments when everything resolves (asserts the ephemeral endpoint is never hit), and names the environment that is actually missing.

Deliberate residual: if the ephemeral endpoint itself errors (it doesn't exist on every server version) the error is swallowed and reported as cannot find an environment with the ID or name of '<identifier>', because the identifier genuinely isn't a regular environment and a "v2 environments endpoint returned 404" message would be worse for the common case. That does mean a real transport failure against that endpoint is reported as not-found.

PR #703 raises the same mixed-list finding; same conclusion, and the fix is in the shared selectors resolver so both branches pick it up.

Comment thread pkg/cmd/runbook/run/run.go Outdated

// the executions API only matches environments and tenants by name, so resolve any IDs we were given
if len(flags.Environments.Value) > 0 {
selectedEnvironments, err := selectors.FindEnvironments(octopus, flags.Environments.Value)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: release deploy got the ephemeral fallback (resolveEnvironmentNames) but the runbook path uses bare selectors.FindEnvironments, which only sees GET /environments/all - and ephemeral environments aren't in that list. Before this PR the value was passed straight through for the server to resolve, so if a runbook run can target an ephemeral environment by name, that now fails client-side with cannot find an environment with the ID or name of '...'.

If runbook runs can never target ephemeral environments this is fine as-is; otherwise this call probably wants the same fallback deploy got.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answering the question: oversight, not intentional. Actioned in 3d8333b (and tightened in 956f445).

runbook run now calls the same selectors.ResolveEnvironmentNames(octopus, f.GetCurrentSpace(), ...) that release deploy's no-prompt path uses, so an environment name that used to be passed through for the server to resolve is no longer rejected client-side just because it isn't in GET /environments/all.

956f445 closes the other half of it: the prompt path used to re-resolve the canonical name through executionscommon.FindEnvironments when picking run targets, which would have failed for an ephemeral environment even after the fallback was in place. That path now reuses the ID the up-front resolve produced, so an ephemeral name survives the whole flow.

Unverified: whether the runbook-run executions API actually accepts an ephemeral environment by name server-side. I have no instance to try it against. What I can say is that the CLI no longer decides the answer for the server — the behaviour is back to "pass it on and let the server rule", which is what it was before this branch. If you know runbook runs can't target ephemeral environments at all, the right change is a specific error message rather than the generic cannot find an environment..., and I'd want that from you rather than guessing.

Comment thread pkg/cmd/runbook/run/run.go Outdated

// the executions API only matches environments and tenants by name, so resolve any IDs we were given
if len(flags.Environments.Value) > 0 {
flags.Environments.Value, err = selectors.ResolveEnvironmentNames(octopus, f.GetCurrentSpace(), flags.Environments.Value)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep the resolved environment identity for later runbook lookups.

This replaces the original identifier with a name, but AskDbRunbookRunQuestions / AskGitRunbookRunQuestions later pass that name through the ID-first executionscommon.FindEnvironments again when selecting run targets. The by-tag path does the same for its prompted-variable preview.

Using the collision already covered by the selector tests: environment A has ID Environments-99 and name Environments-13; environment B has ID Environments-13 and name production. Passing --environment Environments-99 first resolves correctly to A, then the later lookup resolves its name to B. I reproduced that exact two-stage lookup: the final ID is Environments-13 instead of Environments-99. The run still submits A's name, but target selection and prompted-variable checks use B's preview.

Please retain the resolved environment objects/IDs for those lookups, or defer conversion to names until building the execution request. Add a runbook-level collision test covering the target or by-tag preview, rather than only testing each resolver independently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in 956f445. Reproduced your two-stage collision before fixing it, and the resolved identity is now carried through instead of the name being re-resolved.

What changed:

  • selectors.ResolveEnvironments returns []*ResolvedEnvironment{ID, Name} — the ID and name of whichever environment each identifier picks out, regular or ephemeral. ResolveEnvironmentNames is now a thin wrapper over it for callers that only want names (release deploy's no-prompt path).
  • runbookRun resolves once and threads the resolved slice through runDbRunbook / runGitRunbook / runRunbooksByTag and into AskDbRunbookRunQuestions / AskGitRunbookRunQuestions, the same way project is already threaded. Nothing on the command path resolves an environment twice.
  • askRunbookTargets / askGitRunbookTargets now take environmentIDs []string. They only ever used env.ID, and taking IDs makes it impossible to hand them a name to re-resolve.
  • the by-tag prompted-variable preview uses the resolved ID. It also used to call FindEnvironments once per matching runbook; it's now one lookup for the whole loop.
  • the FindEnvironments name lookup survives only as a fallback for the exported Ask* entry points, which can legitimately be called without pre-resolved environments.

Test, as asked, at the runbook level rather than per-resolver: TestRunbookRunByTag_UsesTheResolvedEnvironmentForThePreview in pkg/cmd/runbook/run/run_test.go. It uses your collision (Environments-99/Environments-13 vs Environments-13/production), runs runbook run --runbook-tag nightly --environment Environments-99 in automation mode, and asserts the preview goes to .../runbookRuns/preview/Environments-99 and that /environments/all is fetched exactly once. I checked it fails on the old shape — reinstating the FindEnvironments call makes the mock server report /api/Spaces-1/environments/all where it expected the Environments-99 preview. There's also TestResolveEnvironments in find_test.go pinning the ID/name pair for the colliding identifier.

Two residuals worth naming:

  • askRunbookPreviewVariables still receives the interactively-selected environments only, so it gets nothing when --environment came from the command line, and prompted variables aren't previewed on that path. That's pre-existing and unrelated to the double-resolution, so I left it rather than change interactive behaviour inside this PR. Happy to do it separately — it would mean users who pass --environment interactively start getting prompted-variable questions they don't get today, which is a behaviour change worth its own decision.
  • executionscommon.AskTenantsAndTags likewise still gets the interactive selection only, for the same reason.

release deploy doesn't have the equivalent bug: deployRun never pre-converts options.Environments to names, and AskQuestions resolves them exactly once (deploy.go L492-L500), so there's no second lookup to collide.

NickJosevski and others added 5 commits September 15, 2026 16:57
…enant

The executions API only matches channels, environments and tenants by name,
so `release create`, `release deploy` and `runbook run` passed whatever the
caller typed straight through and the server rejected IDs. `--project`
already worked because the server accepts a project ID or name.

Resolve those identifiers client side through the shared selectors package
before handing them to the executor, preferring an ID match over a name
match so it behaves the same way as `--project`.

Fixes #250

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`Tenants.GetByIdentifier`'s name fallback (`GetByName`) issues a single
`tenants?partialName=<name>` query and scans only the first page of the
result. `partialName` is a contains filter, so an exact name that sorts
past a page's worth of other tenants containing the same substring - e.g.
`--tenant Smith` in a space full of `... Smith` tenants - came back as
`ErrItemNotFound` and failed the deploy, even though the same name worked
before this branch, when it was passed through and matched server side.

`selectors.FindTenant` now does the ID lookup itself and walks every page
of the partial name search looking for an exact match, keeping the same
ID-beats-name precedence.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…emeral fallback with runbook run

The ephemeral fallback was all-or-nothing over the whole `--environment`
list: a list mixing a regular and an ephemeral environment could never
resolve, because the regular lookup errored on the ephemeral name and the
ephemeral lookup then errored on the regular one, leaving the user with
`cannot find an environment with the ID or name of '<ephemeral env>'` -
blaming an environment that exists. It also fell back on *any* error from
the regular lookup, including a transport failure.

`selectors.ResolveEnvironmentNames` now resolves each identifier in turn
against the regular environment list, consulting the ephemeral list only
for identifiers that list doesn't have (fetched once, lazily). Single-type
lists behave exactly as before; mixed lists resolve, and a genuine miss
names the identifier that actually went missing.

`runbook run` uses the same resolver, so an ephemeral environment name that
used to be passed through to the server no longer fails client side.

Also flips ephemeral name/ID indexing in `findEphemeralEnvironments` so an
ID match wins a collision, matching the precedence everywhere else.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`runbook run` resolved `--environment` to canonical names up front, then
handed those names back to the ID-first `executionscommon.FindEnvironments`
when picking run targets and when previewing prompted variables for a
by-tag run. With the collision the selector tests already cover -
environment A is `Environments-99`/`Environments-13` and environment B is
`Environments-13`/`production` - `--environment Environments-99` resolved
to A, and the second lookup then resolved A's name to B. The run still
submitted A's name, but target selection and the prompted-variable check
used B's preview.

`selectors.ResolveEnvironments` now returns the ID and name of each
environment an identifier picks out (`ResolveEnvironmentNames` is a thin
wrapper for callers that only want names), and `runbook run` threads that
resolved identity down through `runDbRunbook`/`runGitRunbook`/
`runRunbooksByTag` and into the Ask* questions, so nothing resolves an
environment twice. The run-target helpers now take environment IDs, since
that's all they ever used. The by-tag preview also stops re-listing every
environment once per matching runbook.

The name lookup is kept as a fallback for the exported `Ask*` entry points,
which can be called without pre-resolved environments.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The --priority tests arrived on main (#708) after this branch was cut, so they
were the only deploy cases not already expecting the environments/all lookup
this branch adds. Same one-line expectation as every other case here.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Some arguments do not accept the ID instead of the name

1 participant